Skip to content

淺談 Entity Framework 中 SaveChanges() 的異常處理與狀態還原

TLDR

  • SaveChanges() 失敗後,Entity 的狀態會被保留,導致後續的寫入操作會包含先前失敗的變更,進而引發連鎖失敗。
  • 建議在 DbContext 中覆寫 SaveChanges()SaveChangesAsync(),透過 ChangeTracker 捕捉 DbUpdateException 並重置 Entity 狀態。
  • 針對 Modified 狀態的 Entity,應使用 entry.CurrentValues.SetValues(entry.OriginalValues) 還原資料並將狀態改為 Unchanged
  • 針對 Added 狀態的 Entity,應將狀態改為 Detached
  • 在涉及外鍵關聯或導覽屬性的複雜結構中,還原 Entity 狀態可能導致快取不一致,不建議在該情境下使用此還原機制。
  • DbSet.Add() 階段發生的 InvalidOperationException(如主鍵衝突)不會被 SaveChanges() 的錯誤處理機制捕獲。

Entity Framework 的常見 Exception

在開發過程中,理解 EF 拋出的例外類型有助於正確處理錯誤:

  • DbUpdateException:當儲存至資料庫時發生錯誤(如違反資料庫約束、連線中斷)時拋出。此例外通常封裝了底層的 SQL 執行錯誤。
  • DbUpdateConcurrencyException:當發生並發衝突時拋出(例如設定了 RowVersionConcurrencyCheck,但資料庫中的資料已被他人修改)。
  • DbEntityValidationException:舊版 EF 的驗證例外,但在 EF Core 中已被移除。建議改用 Model Binding 或 Service Layer 進行資料驗證。

錯誤訊息處理建議

什麼情況下會遇到錯誤訊息過於籠統的問題?當系統直接將原始 Exception 訊息回傳給前端時。

  • 若需對外隱藏細節:應在 Log 中記錄 InnerException 的完整資訊,並僅回傳攏統的錯誤訊息給前端。
  • 若無需對外隱藏:可在 DbContext 中覆寫 SaveChanges(),捕獲例外後重新拋出一個包含完整錯誤訊息的新 Exception,以利權責劃分。

SaveChanges() 失敗時的狀態還原

什麼情況下會遇到狀態還原問題?當開發者依賴資料庫主鍵來阻擋重複資料,且在 SaveChanges() 失敗後未清除 ChangeTracker 中的異動時。

由於 EF 會保留失敗的 Entity 狀態,若第一次 SaveChanges() 失敗,後續的寫入操作仍會嘗試將該筆失敗資料送往資料庫,導致後續操作全數失敗。若希望在失敗後忽略該次異動,可透過覆寫 SaveChanges() 來手動還原狀態。

實作方式

以下為 DbContext 的擴充實作,用於在發生 DbUpdateException 時自動重置狀態:

csharp
public partial class TestEFContext {
    public override int SaveChanges() {
        return SaveChanges(true);
    }

    public override int SaveChanges(bool acceptAllChangesOnSuccess) {
        try {
            return base.SaveChanges(acceptAllChangesOnSuccess);
        } catch (DbUpdateException ex) {
            throw ResetEntityStateAndFixMessage(ex);
        }
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) {
        return SaveChangesAsync(true, cancellationToken);
    }

    public override async Task<int> SaveChangesAsync(
        bool acceptAllChangesOnSuccess,
        CancellationToken cancellationToken = default
    ) {
        try {
            return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
        } catch (DbUpdateException ex) {
            throw ResetEntityStateAndFixMessage(ex);
        }
    }

    private DbUpdateException ResetEntityStateAndFixMessage(DbUpdateException ex) {
        ResetEntityStates(ChangeTracker.Entries());
        return new DbUpdateException(ex.InnerException.Message, ex);
    }

    private static void ResetEntityStates(IEnumerable<EntityEntry> entries) {
        foreach (EntityEntry entry in entries) {
            ResetEntityState(entry);
        }
    }

    private static void ResetEntityState(EntityEntry entry) {
        switch (entry.State) {
            case EntityState.Added:
                entry.State = EntityState.Detached;
                break;
            case EntityState.Modified:
                entry.CurrentValues.SetValues(entry.OriginalValues);
                entry.State = EntityState.Unchanged;
                break;
            case EntityState.Deleted:
                entry.State = entry.Entity is Dictionary<string, object>
                    ? EntityState.Detached
                    : EntityState.Unchanged;
                break;
        }
    }
}

WARNING

使用 DbSet.Add() 加入與已查詢資料具有相同 PK 的 Entity 時,會拋出 InvalidOperationException。由於 Exception 是在 Add() 時拋出,而非在 SaveChanges(),因此不會被上述錯誤處理機制捕獲。

關於外鍵關聯的注意事項

什麼情況下會遇到還原失敗的問題?當 Entity 結構包含複雜的導覽屬性(Navigation Properties)或外鍵關聯時。

在測試中發現,若將 EntityState.Deleted 的關聯 Entity 設為 Unchanged,可能會導致 DbContext 快取機制回傳錯誤的導覽屬性狀態。雖然將其設為 Detached 可解決部分問題,但整體而言,在涉及外鍵關聯的複雜結構中,手動還原 Entity 狀態仍存在潛在的快取不一致風險。

TIP

本篇的完整可執行範例:CloudyWing/EfCoreBehaviorSample


異動歷程

  • 2024-08-17 初版文件建立。
  • 2026-05-29 補上對應 GitHub 範例專案連結。